<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/openlayers/8.2.0/ol.min.css"
    integrity="sha512-bc9nJM5uKHN+wK7rtqMnzlGicwJBWR11SIDFJlYBe5fVOwjHGtXX8KMyYZ4sMgSL0CoUjo4GYgIBucOtqX/RUQ=="
    crossorigin="anonymous" referrerpolicy="no-referrer" />
  <title>Overlay展示点位聚合</title>

  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    html,
    body {
      height: 100%;
      height: 100%;
    }

    #container {
      width: 100%;
      height: 100%;
      overflow: hidden;
      position: absolute;
    }

    #app {
      width: 100vw;
      height: 100vh;
    }

    .app-map {
      height: 100vh;
    }

    .zoom {
      position: fixed;
      right: 10px;
      bottom: 10px;
      padding: 10px;
      background-color: #eee;
      border-radius: 8px;
      color: #07c160;
      font-weight: bold;
    }

    .app-btns {
      position: fixed;
      right: 10px;
      top: 10px;
      background-color: #fff;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
      width: 210px;
      padding: 25px;
      text-align: center;
      border-radius: 5px;
      display: flex;
      flex-direction: column;
    }

    .app-btns button {
      font-size: 18px;
      border: none;
      padding: 12px 20px;
      border-radius: 4px;
      color: #fff;
      background-color: #409eff;
      border-color: #409eff;
      cursor: pointer;
      border: 1px solid #dcdfe6;
      margin-bottom: 5px;
    }

    .app-btns button.active,
    .app-btns button:hover {
      background-color: rgba(7, 193, 96, 0.8);
    }

    @keyframes scale-animation {
      0% {
        transform: scale(1);
      }

      50% {
        transform: scale(1.2);
      }

      100% {
        transform: scale(1);
      }
    }

    .cluster-marker {
      animation: scale-animation 2s infinite;
      border-radius: 50%;
      z-index: 3;
      cursor: pointer;
    }

    .cluster-marker.c30 {
      width: 20px;
      height: 20px;
      line-height: 20px;
      text-align: center;
      font-size: 14px;
      background-color: rgba(0, 255, 168, 0.7);
      box-shadow: 0px 0px 8px 6px rgba(0, 255, 168, 1);
    }

    .cluster-marker.c90 {
      width: 40px;
      height: 40px;
      line-height: 40px;
      text-align: center;
      font-size: 16px;
      background-color: rgba(32, 177, 170, 0.7);
      box-shadow: 0px 0px 8px 6px rgba(32, 177, 170, 1);
    }

    .cluster-marker.c150 {
      width: 70px;
      height: 70px;
      line-height: 70px;
      text-align: center;
      font-size: 18px;
      background-color: rgba(7, 193, 96, 0.7);
      box-shadow: 0px 0px 8px 6px rgba(7, 193, 96, 1);
    }

    .cluster-marker.c250 {
      width: 100px;
      height: 100px;
      line-height: 100px;
      text-align: center;
      font-size: 20px;
      background-color: rgba(255, 215, 0, 0.7);
      box-shadow: 0px 0px 8px 6px rgba(255, 215, 0, 1);
    }
  </style>

</head>

<body>
  <div id="app">
    <div class="app-map" id="app-map"></div>
    <span v-text='mapZoom' class="zoom"></span>
    <div class="app-btns">
      <button @click='handleClickCluster(btn)' v-for='btn in btnData' v-text='btn.text'
        :class='{active: currentDis === btn.px}' :key='btn.px'></button>
      <button @click='toggleCluster'>{{clusterZIndex === -1 ? '显示' : '隐藏'}}聚合图层</button>
      <button @click='toggleOverlay'>{{overlayVisible ? '隐藏' : '显示'}}Overlay图层</button>
    </div>
  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/openlayers/8.2.0/dist/ol.min.js"
    integrity="sha512-+nvfloZUX7awRy1yslYBsicmHKh/qFW5w79+AiGiNcbewg0nBy7AS4G3+aK/Rm+eGPOKlO3tLuVphMxFXeKeOQ=="
    crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.14/vue.global.prod.min.js"
    integrity="sha512-huEQFMCpBzGkSDSPVAeQFMfvWuQJWs09DslYxQ1xHeaCGQlBiky9KKZuXX7zfb0ytmgvfpTIKKAmlCZT94TAlQ=="
    crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  
  <script>
    const { createApp } = Vue;
    // 生成点位聚合显示的数字样式
    const createCountPointStyle = (size) => {
      // 计算一个动态的 radius
      const radius = 20 + Math.max(0, (String(size).length - 2)) * 10;
      const rcolor = '#' + parseInt(Math.random() * 0xffffff).toString(16).padStart(6, '0');
      return new ol.style.Style({
        image: new ol.style.Circle({
          radius,
          stroke: new ol.style.Stroke({
            color: rcolor
          }),
          fill: new ol.style.Fill({
            color: rcolor
          })
        }),
        text: new ol.style.Text({
          text: size.toString(),
          fill: new ol.style.Fill({
            color: '#fff'
          }),
          scale: 2,
          textBaseline: 'middle'
        })
      })
    };
    // 存放各个聚合数字的样式，用于不重复生成各个数量的样式
    const countStyles = {};
    const vm = createApp({
      data() {
        return {
          map: {},
          mapZoom: 5, // 地图层级
          btnData: [{
            text: '50px聚合',
            px: 50,
          }, {
            text: '100px聚合',
            px: 100,
          }, {
            text: '150px聚合',
            px: 150,
          }], // 聚合距离
          currentDis: 150, // 当前聚合距离
          overlayArr: [], // overlay数组
          clusterLayer: {}, // 聚合图层
          clusterZIndex: -1, // 聚合图层层级
          overlayVisible: true // overlay的visible
        }
      },
      methods: {
        // 初始化地图
        initMap() {
          // 高德地图瓦片地址
          const vectorLayer = new ol.layer.Tile({
            source: new ol.source.XYZ({
              url: 'http://wprd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}'
            }),
            name: '初始化地图图层',
            layerID: 'base',
            index: 2
          });
          // 初始化地图
          this.map = new ol.Map({
            target: 'app-map',
            layers: [vectorLayer],
            view: new ol.View({
              projection: 'EPSG:3857',
              //设定中心点，因为默认坐标系为 3587，所以要将我们常用的经纬度坐标系4326 转换为 3587坐标系
              center: ol.proj.transform([111.8453154, 32.7383500], 'EPSG:4326', 'EPSG:3857'),
              zoom: 5,
            })
          });
          // 绑定地图事件
          this.bindMapEvt();
          // 创建随机点位的聚合图层
          this.createRandomCluster(5000);
        },
        // 绑定地图事件
        bindMapEvt() {
          // 监听鼠标点击
          this.map.on('click', (evt) => {
            const clickPoint = ol.proj.transform(evt.coordinate, 'EPSG:3857', 'EPSG:4326')
            console.log('当前点击坐标为 : ' + clickPoint[0].toFixed(7) + ',' + clickPoint[1].toFixed(7));
            const feature = this.map.forEachFeatureAtPixel(evt.pixel, function (feature) {
              return feature;
            });
            // 如果点击的是聚合点，进一步放大层级。如果点击的是具体的点位了（聚合数量是1），获取点位ID，进行下一步操作
            if (feature) {
              // 处理点击聚合图层
              if (this.clusterZIndex === -1) {
                return;
              }
              // 判断是否是聚合点位
              const iamcluster = feature.get('iamcluster');
              if (iamcluster) {
                const count = feature.get('count');
                // 放大地图层级
                if (count > 1) {
                  alert(`这是一个聚合点，下面共有 ${count} 个点位，将以此为中心，增大一级地图显示层级。`);
                  const czoom = this.map.getView().getZoom();
                  this.map.getView().animate({
                    center: evt.coordinate,
                    zoom: czoom + 1
                  });
                  // 弹出ID
                } else {
                  const custom = feature.get('features')[0].get('custom');
                  alert(`这是一个具体的点位，点位的ID是 ${custom.id}。`);
                }
              }
            }
          });
          // 监听鼠标移动，移动到聚合点位上时，鼠标变为可点击的状态
          this.map.on('pointermove', (e) => {
            let pixel = this.map.getEventPixel(e.originalEvent);
            let feature = this.map.forEachFeatureAtPixel(pixel, (feature) => {
              return feature
            });
            if (feature) {
              const iamcluster = feature.get('iamcluster');
              if (iamcluster && this.clusterZIndex == 1) {
                this.map.getTargetElement().style.cursor = 'pointer';
              } else {
                this.map.getTargetElement().style.cursor = 'auto';
              }
            } else {
              this.map.getTargetElement().style.cursor = 'auto';
            }
          });
          // 移动事件，包括鼠标左键移动和缩放，地图右上角显示当前地图层级
          this.map.on('moveend', () => {
            this.mapZoom = this.map.getView().getZoom().toFixed(1);
          });
          // overlay的点击事件
          document.querySelector('body').addEventListener('click', e => {
            if (e.target.classList.contains('cluster-marker')) {
              console.log('overlay下聚合点位数量： ', e.target.dataset.count);
              const ol_uid = e.target.dataset.ol_uid;
              const { features } = this.clusterLayer.getSource();
              for (let i = 0; i < features.length; i++) {
                if (features[i].ol_uid == ol_uid) {
                  const featuresInfo = features[i].get('features');
                  alert('共有' + featuresInfo.length + '个点位，详细信息已在控制台打印');
                  console.log('聚合点位数据为： ', featuresInfo);
                  break;
                }
              }
            }
          })
        },
        // 根据数据创建聚合图层
        createCluster(points, layerID) {
          // 根据points创建一个新的数据源和要素数组，
          const vectorSource = new ol.source.Vector({
            features: points.map(e => {
              // ol.proj.fromLonLat用于将经纬度坐标从 WGS84 坐标系转换为地图投影坐标系
              const feature = new ol.Feature({
                geometry: new ol.geom.Point(ol.proj.fromLonLat(e)),
                custom: {
                  id: Math.ceil(Math.random() * 100000)
                }
              });
              return feature;
            })
          });
          // 根据点位创建聚合资源
          const clusterSource = new ol.source.Cluster({
            distance: this.currentDis, // 设置多少像素以内的点位进行聚合
            source: vectorSource
          });
          // 创建带有数据源的矢量图层，将创建的聚合字段作为source
          this.clusterLayer = new ol.layer.Vector({
            source: clusterSource,
            layerID: layerID,
            style: (feature) => {
              return this.setFeatureStyle(feature)
            },
            zIndex: this.clusterZIndex,
            opacity: 0
          });
          // 将矢量图层添加到地图上
          this.map.addLayer(this.clusterLayer);
          let changed = 0; // 设置overlay的定时器，防止change事件触发多次重复添加overlay
          // 监听图层变化事件，变换后，更新overlay
          clusterSource.on('change', (e) => {
            if (this.overlayVisible === false) {
              return;
            }
            if (changed) {
              clearTimeout(changed);
            }
            changed = setTimeout(() => {
              console.log('渲染overlay');
              this.map.getOverlays().clear();
              this.overlayArr = [];
              if (e.target.features && e.target.features.length > 0) {
                for (let i = 0; i < e.target.features.length; i++) {
                  var count = e.target.features[i].get('count');
                  const { ol_uid } = e.target.features[i]
                  this.createClusterOverlay(e.target.features[i].getGeometry().getCoordinates(), count, ol_uid);
                }
              }
            }, 20);
          });
        },
        // 创建随机点位的聚合点位数据图层
        createRandomCluster(num) {
          const positions = this.createPointsByRange(num); // 生成坐标数据
          this.createCluster(positions, 'all');
        },
        // 根据经纬度创建overlay
        createClusterOverlay(coordinate, count, ol_uid) {
          if (!count) {
            return;
          }
          let element = document.createElement('div');
          element.innerText = count;
          element.dataset.count = count;
          element.dataset.ol_uid = ol_uid;
          element.className = 'cluster-marker ' + this.getOverlayCls(count); // 应用 CSS 类  
          // 将元素添加到 Overlay 容器中  
          const overlay = new ol.Overlay({
            element: element,
            position: coordinate,
            positioning: 'center-center'
          });
          this.overlayArr.push(overlay);
          this.map.addOverlay(overlay);
        },
        // 根据数量判断overlay的dom类名
        getOverlayCls(count) {
          return count < 30 ? 'c30' :
            count < 90 ? 'c90' :
              count < 150 ? 'c150' :
                'c250';
        },
        // 切换聚合图层的显示和隐藏
        toggleCluster() {
          this.clusterZIndex = this.clusterZIndex === -1 ? 1 : -1;
          this.clusterLayer.setZIndex(this.clusterZIndex);
          this.clusterLayer.setOpacity(this.clusterZIndex === -1 ? 0 : 1);
        },
        // 切换overlay的显示和隐藏
        toggleOverlay() {
          if (this.overlayVisible) { // 清空
            this.map.getOverlays().clear();
          } else { // 获取聚合图层的feature，转换为overlay
            const { features } = this.clusterLayer.getSource();
            this.overlayArr = [];
            for (let i = 0; i < features.length; i++) {
              const count = features[i].get('features').length;
              const coordinates = features[i].get('geometry').getCoordinates()
              this.createClusterOverlay(coordinates, count);
            }
          }
          this.overlayVisible = !this.overlayVisible;
        },
        // 设置聚合点的样式
        setFeatureStyle(feature) {
          // 获取聚合点小有几个点位
          const size = feature.get('features').length;
          // 设置聚合点的count参数
          feature.set('count', size); // 设置数量
          feature.set('iamcluster', true); // 增加标识
          // 如果是聚合点，查看countStyles是否存储了这个聚合点的数字样式，如果不存在，生成一个并存储
          if (!countStyles[size]) {
            countStyles[size] = createCountPointStyle(size);
          }
          return countStyles[size];
        },
        // 设置聚合图层的聚合距离
        handleClickCluster(btn) {
          // 聚合距离相同，直接返回不处理
          if (this.currentDis === btn.px) {
            return;
          }
          this.currentDis = btn.px;
          // 设置聚合距离
          // 获取到图层，遍历判断是不是 Cluster 图层，是的话设置聚合距离
          this.map.getLayers().getArray().forEach(layer => {
            const source = layer.getSource();
            if (source instanceof ol.source.Cluster) {
              source.setDistance(btn.px);
            }
          });
        },
        // 根据范围随机生成经纬度点位 rangeArr = [minLat, maxLat, minLon, maxLon]
        createPointsByRange(num, rangeArr = [3.86, 53.56, 73.66, 135.05]) {
          const [minLat, maxLat, minLon, maxLon] = rangeArr;
          const points = [];
          for (var i = 0; i < num; i++) {
            var lat = Math.random() * (maxLat - minLat) + minLat;
            var lon = Math.random() * (maxLon - minLon) + minLon;
            points.push([lon, lat]);
          }
          return points;
        },
      },
      mounted() {
        this.initMap();
      }
    }).mount('#app')
  </script>
  
</body>

</html>